배경
-
Go 애플리케이션을 종료할 때 특정 로직을 수행하고자 했습니다.
-
사용자가 수동으로 (로컬) 서버를 종료하면 syscall.SIGINT 값이 channel 에 할당되면서 goroutine 내부가 실행되어 로직 수행 후 애플리케이션이 종료(
os.Exit(1)
) 됩니다. -
실제로 로컬에서 서버를 실행하고
ctrl + C
명령어로 서버를 종료하면 goroutine 내부 로직이 잘 실행됐습니다.
func (s *Server) StartServer() error { s.setServerInfo() channel := make(chan os.Signal, 1) signal.Notify(channel, syscall.SIGINT) // (1) 단순 종료 시그널 go func() { <-channel // ... 로직 수행 os.Exit(1) }() return s.engine.Listen(s.port) }
도커 컨테이너화 후에도 잘 동작할까?
-
애플리케이션을 컨테이너로 말아올려 실행하더라도 위의 로직이 잘 동작할까요? 그렇지 않습니다. 실행중인 컨테이너를
stop
하더라도 위의 goroutine 은 수행되지 않습니다. -
이는 도커 컨테이너에서 신호 처리 방식이 로컬 환경과 다르기 때문인데, 도커에서는 컨테이너를 stop 할 때, 기본적으로
SIGTERM
신호를 보냅니다. (그 후에 일정 시간 동안 컨테이너가 종료되지 않으면SIGKILL
신호를 보냅니다.) -
기존 코드에서는
signal.Notify(channel, syscall.SIGINT)
를 사용하고 있어 어플리케이션 종료 사인으로 SIGINT 신호만을 처리하고 있습니다. 즉docker stop <container id>
명령 사인을 처리하지 못하기 때문에, 현재 코드에서는 docker container 가 내려가도 goroutine 로직이 정상적으로 수행되지 않습니다. -
도커 컨테이너가 가 stop 될 때도 로직을 수행하기 위해선 signal.Notify() 코드 내부에 도커 컨테이너가 stop 될 때의 시그널을 인자로 추가하면 됩니다. 개선된 코드는 아래와 같습니다.
func (s *Server) StartServer() error { s.setServerInfo() channel := make(chan os.Signal, 1) signal.Notify(channel, syscall.SIGINT, syscall.SIGTERM을) // 단순 종료 시그널 + Docker container stop 시그널 go func() { <-channel // 로직 수행 os.Exit(1) }() return s.engine.Listen(s.port) }
-
이제 문제가 모두 해결됐을까요? 이론상으로는 그렇습니다. 하지만 제가 실행했을 땐 여전히 문제가 풀리지 않더라구요. (진리의 제 컴에선 안돼요 ㅠ)
-
GPT 의 도움을 받았습니다.
-
Docker PID 가 1이 아닐 수 있나? 라는 생각이 들었지만, 확실치 않았기에 실행 중인 컨테이너의 PID 를 확인했습니다.
$ docker top <container id> # container PID 확인
- 아니나 다를까 PID 는 1이 아니었습니다. 🥲🥲
컨테이너의 PID 1이 중요한 이유:
-
PID 1 프로세스는 리눅스에서 중요한 역할을 합니다. 리눅스 시스템에서 PID 1은
최상위 프로세스
이며 시스템에서 발생하는 신호를 처리하고 자식 프로세스가 종료될 때 좀비 프로세스를 청소하는 역할을 합니다. -
컨테이너 환경에서도 `PID 1 프로세스는 도커가 전달하는 신호(SIGTERM, SIGINT 등)를 처리하고, 컨테이너 종료 시 제대로 된 정리 작업을 수행합니다.
-
따라서 애플리케이션이 PID 1로 실행되지 않으면 신호를 제대로 수신할 수 없기 때문에 예상한대로 로직이 실행되지 않았던 것이었습니다.
컨테이너의 PID 가 1이 아니었던 이유
-
저는 컨테이너를 도커 컴포즈로 실행하고 있었습니다.
-
컴포즈로 실행하는건 문제가 안되지만 컨테이너의 PID 를 1로 실행하기 위해선 컴포즈 파일 내부의 명령어 수정이 필요했습니다. 기존 도커 컴포즈 파일은 아래와 같습니다.
version: '3' services: chat_backend_1: build: context: ./chat_backend dockerfile: Dockerfile ports: - "1011:1010" volumes: - ./chat_backend:/go/src/app working_dir: /go/src/app environment: - GO111MODULE=on command: sh -c "go build -o main . && ./main" # << 변경이 필요한 지점 # ... 이하 생략
-
컨테이너가 실행되는 프로세스가 PID 1이 되도록 하려면, command 섹션에서 쉘 스크립트나 명령어를 실행할 때
exec
명령을 사용해야 합니다. -
exec
는 현재 쉘 프로세스를 대체하기 때문에, 실행되는 애플리케이션의 PID 를 1로 설정할 수 있습니다.
version: '3' services: chat_backend_1: build: context: ./chat_backend dockerfile: Dockerfile ports: - "1011:1010" volumes: - ./chat_backend:/go/src/app working_dir: /go/src/app environment: - GO111MODULE=on command: sh -c "go build -o main . && exec ./main" # << exec 추가 # ... 이하 생략
- 변경된 컴포즈 파일을 빌드하여 도커 컴포즈를 실행했습니다. 결과적으로 PID 가 1로 잘 출력되는 걸 확인할 수 있었고,
PID 1 로 컨테이너를 실행한 후에는 위 goroutine 코드가 정상적으로 동작하는걸 확인할 수 있었습니다.
결론:
-
Go 애플리케이션을 도커 컨테이너로 운영할 때 발생할 수 있는 예상치 못한 문제와 해결 과정에 대해 작성했습니다.
-
신호 처리의 중요성
: UNIX 계열 운영체제에서 사용되는 신호 SIGINT 와 SIGTERM 의 차이를 이해하고, 컨테이너 환경에서 처리하기 위해 적절한 명령어를 숙지해야 하는 점을 배웠습니다. 특히 도커 컨테이너에서는 SIGTERM 신호 처리가 필수! -
PID 1의 역할
: 컨테이너 내에서 애플리케이션이 PID 1로 실행되어야 신호를 제대로 받아 처리할 수 있습니다. -
도커 컴포즈 설정
: exec 명령어를 사용하여 애플리케이션을 PID 1로 실행해야 하는 점을 배웠습니다. 컨테이너 운영에 있어 중요한 팁이라 생각합니다.
-
@huge.hoo